feat(chat): add conversation history and message actions#20
feat(chat): add conversation history and message actions#20Dennis-Huangm wants to merge 3 commits intoOpenDCAI:mainfrom
Conversation
store chat/agent conversations per project in local storage and restore the latest thread by mode on load add conversation controls for new, history, clear, rename, and delete to make thread management easier add assistant message actions for copy and regenerate, plus enter-to-send and in-flight guards to prevent duplicate requests update i18n strings and styles to support the new history and action UI (cherry picked from commit e79c664)
There was a problem hiding this comment.
Pull request overview
This PR adds local (per-project) persistence and basic management for chat/agent conversation history in the editor’s chat panel, along with UI affordances for history, copy, and regenerate.
Changes:
- Persist and restore chat/agent conversations via
localStorage, with basic conversation management (create/load/rename/clear/delete). - Add UI controls for conversation history, title display/rename, copy assistant message, and “regenerate” latest assistant response.
- Add corresponding styling and i18n strings; update
.gitignorefor local tool/config directories.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/frontend/src/i18n/locales/zh-CN.json | Adds new UI strings for conversation/history actions in Chinese locale. |
| apps/frontend/src/i18n/locales/en-US.json | Adds new UI strings for conversation/history actions in English locale. |
| apps/frontend/src/app/EditorPage.tsx | Implements conversation persistence lifecycle, UI actions (history, rename, clear/delete), and message-level copy/regenerate. |
| apps/frontend/src/app/App.css | Adds styles for conversation history UI and message action buttons. |
| .gitignore | Ignores local Playwright MCP and Claude local settings files. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const loaded = loadConversations(projectId); | ||
| setConversations(loaded); | ||
| const latest = loaded.find((c) => c.mode === assistantMode); | ||
| if (latest) { | ||
| setActiveConversationId(latest.id); | ||
| activeConvIdRef.current = latest.id; | ||
| if (latest.mode === 'chat') setChatMessages(latest.messages); | ||
| else setAgentMessages(latest.messages); |
There was a problem hiding this comment.
The reload restore logic only looks for the first conversation matching the current assistantMode (which defaults to 'agent'). This does not restore the most recently updated conversation overall (and may restore an older agent chat even if the last used conversation was in chat mode), which conflicts with the PR description. Consider selecting the latest conversation by updatedAt regardless of mode and then setting assistantMode/messages based on that conversation (or persist last active conversation id + mode separately).
| const now = new Date().toISOString(); | ||
| const baseConversations = conversationsRef.current; | ||
| const existing = originConversationId | ||
| ? baseConversations.find((c) => c.id === originConversationId && c.mode === mode) |
There was a problem hiding this comment.
persistCurrentConversation uses conversationsRef.current as the source of truth, but conversationsRef.current is only populated via a useEffect after setConversations(loaded). If the user sends a message before that effect runs (first render after navigation/reload), baseConversations will be [] and persistConversations() will overwrite localStorage, potentially dropping previously saved history. Consider initializing conversationsRef.current synchronously when loading (and/or initializing state from loadConversations(projectId)), or disabling send until conversations are loaded.
| function loadConversations(pid: string): Conversation[] { | ||
| if (typeof window === 'undefined' || !pid) return []; | ||
| try { | ||
| const raw = window.localStorage.getItem(HISTORY_KEY_PREFIX + pid); | ||
| if (!raw) return []; | ||
| return JSON.parse(raw) as Conversation[]; |
There was a problem hiding this comment.
loadConversations() blindly casts JSON to Conversation[] without validating shape. If localStorage is corrupted or schema changes (e.g., missing messages/updatedAt), this can later break rendering/sorting (e.g., conv.messages undefined, invalid dates causing NaN). Consider validating that the parsed value is an array and that each item has expected fields (and filtering/clearing invalid entries) before returning.
| function loadConversations(pid: string): Conversation[] { | |
| if (typeof window === 'undefined' || !pid) return []; | |
| try { | |
| const raw = window.localStorage.getItem(HISTORY_KEY_PREFIX + pid); | |
| if (!raw) return []; | |
| return JSON.parse(raw) as Conversation[]; | |
| function isValidConversation(value: unknown): value is Conversation { | |
| if (!value || typeof value !== 'object') return false; | |
| const v = value as Partial<Conversation> & { [key: string]: unknown }; | |
| if (typeof v.id !== 'string') return false; | |
| if (typeof v.title !== 'string') return false; | |
| if (v.mode !== 'chat' && v.mode !== 'agent') return false; | |
| if (!Array.isArray(v.messages)) return false; | |
| if (typeof v.createdAt !== 'string' || typeof v.updatedAt !== 'string') return false; | |
| const createdTime = new Date(v.createdAt).getTime(); | |
| const updatedTime = new Date(v.updatedAt).getTime(); | |
| if (Number.isNaN(createdTime) || Number.isNaN(updatedTime)) return false; | |
| return true; | |
| } | |
| function loadConversations(pid: string): Conversation[] { | |
| if (typeof window === 'undefined' || !pid) return []; | |
| try { | |
| const key = HISTORY_KEY_PREFIX + pid; | |
| const raw = window.localStorage.getItem(key); | |
| if (!raw) return []; | |
| const parsed = JSON.parse(raw); | |
| if (!Array.isArray(parsed)) { | |
| return []; | |
| } | |
| const validConvs = parsed.filter(isValidConversation); | |
| // If some entries were invalid, persist the cleaned list back to storage. | |
| if (validConvs.length !== parsed.length) { | |
| try { | |
| window.localStorage.setItem(key, JSON.stringify(validConvs)); | |
| } catch { | |
| // ignore secondary persistence errors | |
| } | |
| } | |
| return validConvs; |
apps/frontend/src/app/EditorPage.tsx
Outdated
| // Set prompt and trigger send on next tick | ||
| setPrompt(userPrompt); | ||
| setTimeout(() => { | ||
| sendPromptRef.current?.(); | ||
| }, 0); |
There was a problem hiding this comment.
handleRetryMessage sets state (setPrompt(userPrompt)) and then triggers sendPromptRef.current via setTimeout(…, 0). In React 18 concurrent rendering, there's a risk the re-render hasn’t committed before the timeout fires, so sendPrompt() may still read the previous prompt value. Consider changing sendPrompt to accept an explicit prompt argument for retries, or maintain a promptRef that is updated immediately and read inside sendPrompt.
| // Set prompt and trigger send on next tick | |
| setPrompt(userPrompt); | |
| setTimeout(() => { | |
| sendPromptRef.current?.(); | |
| }, 0); | |
| // Set prompt and trigger send with explicit prompt | |
| setPrompt(userPrompt); | |
| sendPromptRef.current?.(userPrompt); |
| const handleClearConversation = useCallback(() => { | ||
| if (!window.confirm(t('确定清空当前对话?'))) return; | ||
| if (assistantMode === 'chat') setChatMessages([]); | ||
| else setAgentMessages([]); | ||
| if (activeConvIdRef.current) { | ||
| const next = conversationsRef.current.filter((c) => c.id !== activeConvIdRef.current); | ||
| persistConversations(projectId, next); | ||
| setConversations(next); | ||
| conversationsRef.current = next; | ||
| } |
There was a problem hiding this comment.
The "clear" action currently removes the conversation from history (filters it out of conversationsRef.current) which is effectively a delete, while the UI/confirmation copy implies clearing the current conversation’s messages. Consider either (a) clearing messages but keeping the conversation entry (and updating updatedAt), or (b) renaming the action/copy to indicate deletion to avoid surprising data loss.
| <button onClick={sendPrompt} className="btn full" disabled={sendInFlightRef.current}> | ||
| {assistantMode === 'chat' ? t('发送') : t('生成建议')} |
There was a problem hiding this comment.
sendInFlightRef.current is used to drive the button disabled prop, but refs don’t trigger re-renders. This can leave the UI enabled/disabled out of sync in cases where sendInFlightRef.current changes without another state update in the same tick. Consider using a useState flag for in-flight state (and keep the ref only if you need it for guards), so the disabled state reliably updates.
| .chat-msg:hover .msg-actions { | ||
| opacity: 1; | ||
| } | ||
|
|
There was a problem hiding this comment.
On touch devices, .msg-actions is only shown on .chat-msg:hover, which may make the copy/redo actions effectively unreachable. Consider adding a @media (pointer: coarse) rule similar to .history-item-delete/.history-item-rename to keep .msg-actions visible (or provide an always-visible affordance) on coarse pointers.
| /* Ensure message actions are visible on touch / coarse pointer devices */ | |
| @media (pointer: coarse) { | |
| .msg-actions { | |
| opacity: 1; | |
| } | |
| } |
|
I pushed a follow-up fix for the retry flow. Previously, retry depended on asynchronous state updates before resending, which could cause the request to use stale prompt/history values. The resend path I also updated the PR title and description to better match the current scope of the changes. |
|
I pushed another follow-up fix for cross-project async state races. This update makes the conversation persistence flow project-scoped, so late responses from a previous project no longer write into the current project's chat state after |
Summary
This PR improves the editor chat panel with persistent conversation history and message-level actions.
Changes included
Motivation
At the moment, chat history is lost after refreshing the page, which breaks continuity during editing sessions.
This PR aims to make the chat experience more practical by preserving local conversation history across reloads and adding a couple of small interaction
improvements for response reuse and retry.
Testing
npm run buildNotes
I tried to keep the changes limited to chat-related UX improvements.
Feedback is very welcome if this would be better split into separate PRs.
Screenshot
Conversation history and message actions in the editor chat panel
Thank you for reviewing this PR.